Skip to content

refactor: convert the courseware metadata fetch to React Query - #2023

Open
brian-smith-tcril wants to merge 2 commits into
bsmith/react-query-courseware-container-typescriptfrom
bsmith/react-query-courseware-metadata
Open

refactor: convert the courseware metadata fetch to React Query#2023
brian-smith-tcril wants to merge 2 commits into
bsmith/react-query-courseware-container-typescriptfrom
bsmith/react-query-courseware-metadata

Conversation

@brian-smith-tcril

@brian-smith-tcril brian-smith-tcril commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Convert the courseware metadata fetch (fetchCourse) to React Query, and extend the model-store bridge to mirror collection results. Part of the Redux → React Query migration (#1946), the #1976 courseware decomposition (Target 1); stacked on the CoursewareContainer de-class (#2020) and TypeScript (#2021) layers as the new top of the stack. Two commits — #2009 (bridge → collections) and #2010 (courseware metadata). Closes #2009, #2010.

fetchCourse fetched four things (course metadata, the learning-sequences outline, course-home metadata, sidebar toggles) and derived courseStatus. This moves the three data fetches to query hooks (mirrored into the model store via the bridge so the existing useModel readers keep working), moves the status derivation into transitional bridge hooks, and thins fetchCourse to just the un-converted sidebar-toggles fetch.

What changed

  • courseware/data/apiHooks.ts / queryKeys.ts (new) — useCoursewareMetadata (getCourseMetadata) and useCoursewareOutline (getLearningSequencesOutline), tagged with meta.models so the bridge fans each result into the right model(s). courseId is string | undefined (from useParams) with an enabled guard; key factories stay strict string.
  • course-home/data/modelStoreBridge.ts (Peel: extend the model-store bridge to collection dispatches #2009) — the bridge's meta gains a models: [{ modelType, strategy, source? }] list form, so one query result can fan out to several model targets with the right add-vs-merge semantics (the outline writes three: coursewareMeta/sections/sequences).
  • courseware/data/statusBridge.ts (new) — useCourseStatusBridge (container) and useCourseExitStatusBridge (CourseExit) run the queries and mirror their combined state into state.courseware.courseStatus/courseId, so the still-Redux readers (redirect helpers, selectors, TabPage's string status, the exit-page children) keep working. Transitional — removed when those readers move to React Query.
  • CoursewareContainer.tsx — calls useCourseStatusBridge(routeCourseId); the memoized checkFetchCourse guard is byte-identical to base (it now dispatches the thinned fetchCourse).
  • CourseExit.jsx — self-wrapping on the query hooks via useCourseExitStatusBridge, rendering TabPage itself.
  • courseware/data/thunks.jsfetchCourse thinned to just the sidebar-toggles fetch (its full conversion is Convert the courseware outline sidebar to React Query + context #2013); its model writes move to the bridge and its status derivation to the status bridges.
  • queryClient.ts / data/http-error.ts — the app QueryCache's onError logs query failures; a query overrides the level per HTTP status via meta.logStatusAs: { <status>: <level> } (the outline's expected 403 → logInfo). meta is typed globally via a Register.queryMeta augmentation, so onError and the bridge read it cast-free. Implements the last piece of Restore dropped query error logging via a global QueryCache.onError #2022.
  • useIFrameBehavior.ts — the post-event refetch invalidates the courseware queries instead of dispatching fetchCourse.
  • index.jsx — the courseware route drops the <TabContainer fetch={fetchCourse}> wrapper for a bare <CourseExit />.

Behavior

No user-facing change. Metadata/outline/courseHomeMeta now load via React Query and populate the model store through the bridge; courseStatus/courseId are still written (transitionally, by the status bridges) so the redirects, gating, and TabPage behave as before. The sidebar toggles still load via the thinned fetchCourse. Query error logging is preserved, including the outline's 403 → logInfo (a logged-out learner's expected redirect is logged at info level, not surfaced as an error).

Testing

npm run types, npm run lint, and the full npm test suite pass (109 suites / 912 passing / 3 pre-existing skips at PR-open; the fix below adds courseware/data/apiHooks.test.tsx, +1 suite / +1 test). queryClient.test.ts covers onError (default logError, logStatusAs override) and the model-store bridge; modelStoreBridge.test.ts covers the list-form fan-out; the container / CourseExit / useIFrameBehavior / ProductTours tests render through the bridged query client; setupTest's seedCoursewareModels replaces the executeThunk(fetchCourse) seed.

Manual browser verification is done (tutor local, DemoX), confirming no user-facing change: cold load / hard reload, the full set of URL → redirect rules, prev/next sequence navigation (within and across sequences), unit completion, the course-exit / celebration page, and preview mode all behave as before. The sequence-navigation data path is additionally covered by a new courseware/data/apiHooks.test.tsx, which asserts the coursewareMeta mirror preserves sectionIds regardless of query-resolution order (see the coursewareMeta mirror note in the decision log). Items needing a specific user/backend state (access-denied, the outline's expected 403 telemetry, a forced 5xx, sidebar toggles) are left to the automated suite. Full checklist below.

Manual testing checklist & findings

Run against a live backend (tutor local, DemoX). Since this conversion claims no
user-facing change, the pass confirms equivalence end-to-end with real data, a real unit
iframe, and real redirects — what the jest suite can't fully exercise.

Verified by hand:

  • Cold load + hard reload on a unit URL — the bridge fans the outline into
    coursewareMeta/sections/sequences and the metadata into
    coursewareMeta/courseHomeMeta; page renders fully (title/header, unit content,
    outline sidebar, iframe) with no flash of missing structure.
  • Resume redirect (course root → last-active unit, or first sequence if fresh) —
    checkResumeRedirect.
  • Section-in-URL → the section's first sequence (checkSectionToSequenceRedirect), then
    that sequence's activeUnitIndex unit (saved-position resume, unchanged by refactor: convert the courseware metadata fetch to React Query #2023);
    a never-visited chapter deterministically lands on the sequence's unit 1.
  • Section + unit → drops the section and resolves the unit to its real parent sequence
    (checkSectionUnitToUnitRedirectcheckUnitToSequenceUnitRedirect).
  • Unit-only redirect → fills in the parent sequence (checkUnitToSequenceUnitRedirect).
  • Unit markers /first and /last → first / last unit of the sequence.
  • Sequence + unit navigation (prev/next, within and across sequences) — structure and
    order match the outline.
  • Unit completion after interaction — the unit iframe is not torn down by the
    invalidation. The completion checkmark updates on navigation, not instantly:
    confirmed not a regression — the checkmark reads units[].complete from the
    sequence metadata (fetchSequence), which neither the old dispatch(fetchCourse) nor
    the new invalidation refetches; refactor: convert the courseware metadata fetch to React Query #2023 invalidates exactly the three queries
    fetchCourse used to (courseware metadata, outline, course-home metadata).
  • Course exit / celebration — CourseExit (self-wrapping via useCourseExitStatusBridge)
    renders the correct state.
  • Preview mode — renders; identical to the normal URL, which is expected: refactor: convert the courseware metadata fetch to React Query #2023
    touches no preview-sensitive path (isPreview only affects fetchSequence and the
    CoursewareContainer redirect prefix).

Not exercised by hand (need a specific user/backend state; covered by the suite):

  • Access-denied / unenrolled learner gating (courseHomeMeta.courseAccess).
  • Outline expected 403 → logInfo telemetry (logged-out learner).
  • Genuine failure (5xx) → error UI + logError.
  • Sidebar toggles (the one fetch not converted — thinned fetchCourse).

Covered by the automated suite instead:

  • Bridge fan-out (list-form add-vs-merge) — modelStoreBridge.test.ts.
  • Status-bridge derivation — statusBridge.test.ts.
  • onError default + logStatusAs override — queryClient.test.ts.
  • Redirect-helper branches — CoursewareContainer.test.jsx (70 tests).
  • CourseExit / useIFrameBehavior / ProductTours render paths — their suites via the
    bridged client.
  • Thinned fetchCourse (sidebar toggles) — redux.test.js.

Decisions

Full decision log

Decisions — courseware metadata → React Query (+ bridge collections) (#2009 + #2010)

Working notes for this PR (part of the wider Redux → React Query migration,
#1946). Not checked in — referenced when opening the PR. Part of the #1976
courseware decomposition (Target 1). Stacked on the de-class (#2020) and the TS
conversion (#2021).

Why #2009 and #2010 are one PR (two commits). #2009 (the bridge extension) has
no runtime effect on its own — nothing uses the new meta form until #2010 wires
a query to it — and we expect to tweak the bridge while doing #2010. Landing them
together ships a "real" chunk (courseware metadata actually on React Query) instead of
a dormant infra PR followed by its only consumer. Two commits keep the concerns
legible: commit 1 = bridge, commit 2 = the metadata conversion.


Part 1 — extend the model-store bridge to collections (#2009, commit 1)

Extend the transitional bridge (src/course-home/data/modelStoreBridge.ts) so a query
can mirror collection results (and several model targets) into the models store,
not just a single addModel.

Why: the courseware producers write collections, and one fetch → many models

fetchCourse writes four model types with mixed strategies; the outline endpoint alone
writes three from one response:

source model type action
getCourseMetadata coursewareMeta addModel (data has its own id)
getCourseHomeCourseMetadata courseHomeMeta addModel keyed by courseId
getLearningSequencesOutline.courses coursewareMeta updateModelsMap (merge sectionIds)
getLearningSequencesOutline.sections sections addModelsMap
getLearningSequencesOutline.sequences sequences updateModelsMap (merge)

The bridge runs in the QueryCache onSuccess, which receives the query's raw
result, so a query whose result is { courses, sections, sequences } must fan that one
result out to three mirrors.

Contract: keep the single form, add a models list

  • { modelType, courseId } (unchanged) — mirror the whole result as one model keyed
    by courseId. The course-home tabs use this; byte-compatible.
  • { models: [{ modelType, strategy, source? }] } (new) — one or more mirrors.
    strategy is a model-store action (addModel / updateModel / addModelsMap /
    updateModelsMap / updateModels); source selects a key of the result (omitted =
    the whole result). Lets one query populate several targets with the right add-vs-merge
    semantics; source is what avoids splitting the outline into 3 fetches.

Alternatives considered

  • Single-target + a collection flag. Rejected: the bridge sees the raw result and
    meta is one-target-per-query, so the outline's 3-in-1 shape couldn't be expressed
    without 3 separate fetches (3× network) or a bespoke onSuccess.
  • Bespoke per-query handlers. Same objection — abandons the shared bridge.

North star

The bridge is throwaway scaffolding, deleted with the model store in #1977. After that,
readers stop calling useModel(...) and read from the RQ hooks directly. A couple of
models are assembled from two endpoints (sequences = outline shallow +
getSequenceMetadata full; coursewareMeta = getCourseMetadata + outline
sectionIds), so those readers combine the relevant hooks — a #2011/#2013/#1977
concern, not this PR.

Tests

modelStoreBridge.test.ts drives real queries through createModelStoreQueryCache(store)
and asserts the resulting models state: single form, list-form fan-out via source,
updateModelsMap merging (not clobbering), updateModels over an array, and the no-op
when meta is absent.


Part 2 — convert courseware metadata to React Query (#2010, commit 2)

Convert the courseware metadata fetch (fetchCourse) to React Query, mirroring into the
model store via the Part-1 bridge so the ~14 useModel readers keep working.

Scope: convert the metadata/outline/courseHomeMeta fetches; thin fetchCourse in place

fetchCourse did four fetches (metadata, outline, courseHomeMeta, sidebar toggles) + set
courseStatus. Its consumers: CoursewareContainer (player), the CourseExit route
(<TabContainer fetch={fetchCourse}>), useIFrameBehavior (refetch on an iframe event), and
setupTest's initializeTestStore. Decision: move the three data fetches to RQ hooks and
the status derivation into the container, then thin fetchCourse in place
so it does only
the un-converted remainder (the sidebar toggles) — rather than deleting it and adding a new
fetchCoursewareOutlineSidebarToggles thunk.

  • Why thin, not delete+recreate: Convert the courseware outline sidebar to React Query + context #2013 removes the toggles (its last responsibility) and
    deletes fetchCourse then. Renaming/replacing it now is churn for a transitional step —
    CoursewareContainer's guard (checkFetchCoursedispatch(fetchCourse(id))) stays
    byte-identical to the base, and fetchCourse visibly shrinks (4 fetches → 1) across
    layers until it's gone. Transitional cost: fetchCourse is briefly a misnomer (it only
    fetches toggles now).
  • Converting all consumers (not just the player) avoids the query hooks and
    useIFrameBehavior double-populating models on the player.

The query hooks (new courseware/data/apiHooks.ts + queryKeys.ts)

  • useCoursewareMetadata(courseId)getCourseMetadata,
    meta: { models: [{ modelType: 'coursewareMeta', strategy: 'addModel' }] } (result has its own id).
    • Why the array form here and not the single { modelType, courseId } form (as
      useCourseHomeMeta uses)?
      The single form (bridge line 44) does
      addModel({ model: { id: courseId, ...data } }) — its purpose is to inject courseId
      as the id
      , which courseHomeMeta needs because its payload has no id of its own.
      coursewareMeta's payload has its own id, and fetchCourse stored it with
      addModel({ model: metadata }) (no injection). The array form + strategy: 'addModel'
      (no source) maps exactly to that. The two happen to produce the same stored value here
      (in { id: courseId, ...data } the spread wins, and data.id === courseId on this
      route), so the single form wouldn't break — but the array form (a) expresses the real
      intent ("store by the model's own id") instead of relying on the spread-override
      coincidence, (b) is the byte-analog of fetchCourse's call, and (c) keeps both courseware
      hooks on one contract (the outline hook must use the array form to fan out to 3 targets).
  • useCoursewareOutline(courseId)getLearningSequencesOutline,
    meta: { models: [ courses→coursewareMeta/updateModelsMap, sections→addModelsMap, sequences→updateModelsMap ] }.
  • Reuse useCourseHomeMeta for courseHomeMeta — don't re-fetch it.
    • Faithfulness note: useCourseHomeMeta fetches the 'outline' rootSlug variant;
      fetchCourse used 'courseware'. rootSlug only renames the courseware tab's slug
      in the normalized tabs; courseAccess (what the gating reads) is identical. So
      reuse is faithful for gating; the only difference is that tab's slug.

courseId typing: guarded hooks, strict keys (a migration-wide convention)

useParams() types every route param as string | undefined — React Router can't prove
which route a component renders under — even though :courseId is always present on the
courseware/course-exit routes. This bit only surfaces now because #2010 adds the first
typed (.tsx) caller
of these hooks (CoursewareContainer); the existing course-home
callers are all .jsx, so the argument was never type-checked. Every future .tsx
conversion hits the same thing, so we picked one convention:

  • Hooks take string | undefined + enabled: !!courseId. The hook honestly tolerates
    the useParams type by not firing when the id is absent (the standard React Query
    idiom for "param may not be ready"). Call sites pass useParams straight through — no
    casts or guards proliferating across the migration. No-op for the 6 existing .jsx
    callers, since courseId is never actually undefined there.
  • Key factories take string (never undefined). A query key is a real identity; a
    key on undefined is meaningless. So queryKeys.ts stays strict.
  • The seam is a ! at the key call inside the hookcoursewareQueryKeys.metadata(courseId!).
    This is deliberate, not sloppy: queryKey is evaluated eagerly (React Query computes
    it every render regardless of enabled), so the factory is still called when the query
    is disabled. The ! says "keys are built from real ids"; the adjacent enabled: !!courseId
    is what actually makes the never-happens undefined case safe (no fetch). Runtime-wise the
    ! is purely type-level.

Alternative considered — narrow courseId to string once at the .tsx boundary
(then keys and hooks are string, no enabled, no !). Rejected: it relocates the
useParams undefined into a guard/assertion at every typed boundary — the
cast-in-the-wrong-place friction from #2019 — instead of handling it once, idiomatically,
in the hook. (This also reverts a stringstring | undefined widening of
useCourseHomeMeta/courseHomeQueryKeys.metadata that #2010 briefly introduced before we
settled on this convention.)

Error logging: global QueryCache.onError + the outline's 403 nuance

fetchCourse logged each endpoint independently: logError on failed metadata/courseHomeMeta/
toggles, and for the outline a 403 ? logInfo : logError split (a 403 there is the expected
access-denied case — the learner is redirected — so it's logged via logInfo, not logError,
which would surface it as a noticed error).

React Query v5 removed onError from useQuery (it's only on useMutation and the
QueryCache), so per-hook query logging isn't possible. The home for query error logging is the
global QueryCache.onError — permanent app infra, introduced with the QueryCache in #1987
and tracked by #2022. By default it logErrors.

What #2010 adds — the courseware outline is the one query that surfaces a 403 as an error
(its getter throws it; the course-home tab getters swallow 401/403/404 → {}, so their query
errors are only genuine failures). So:

  • useCoursewareOutline tags meta: { logStatusAs: { 403: 'info' } }.
  • onError reads query.meta.logStatusAs — a status → LogLevel map, defaulting to
    error
    (it says how to log each status, not a "quiet" flag). So an outline 403 →
    logInfo, restoring fetchCourse's behavior; anything else → logError. LogLevel
    ('error' | 'info') derives from a { error: logError, info: logInfo } map — the only two
    loggers platform exposes.
  • The toggles keep their logging via the thinned fetchCourse's catch → logError.

Typed meta, no casts. meta is typed globally via a Register.queryMeta augmentation
(ModelStoreMeta & { logStatusAs?: Record<number, LogLevel> }), so both onError and the
model-store bridge read query.meta without a cast, and meta literals are checked at the
write site. getResponseStatus (data/http-error.ts) reads the error's status. At #1977 the
augmentation drops its ModelStoreMeta half along with the bridge.

Migration-wide context (#2022): other converted queries dropped their thunks' logError too,
but their getters swallow 401/403/404, so the global onError (plain logError) already covers
their genuine failures without per-query meta. The outline is the exception that needs the
logStatusAs tag.

Access-gating + status: a transitional useCourseStatusBridge

The old fetchCourse derived courseStatus (request → success/denied/failure) from
courseAccess.hasAccess + outline success and dispatched fetchCourse{Request,Success, Denied,Failure}. That derivation moves out of the thunk into a transitional bridge hook,
useCourseStatusBridge (courseware/data/statusBridge.ts): it runs the three query hooks and
mirrors their combined state into state.courseware.courseStatus via the same status actions,
so the still-Redux readers (the container's redirect helpers/selectors, TabPage's string
status, useContextId) keep working. CoursewareContainer just calls
useCourseStatusBridge(routeCourseId).

Why a bridge, and why a component hook (not the model-store one): it's the same "keep Redux
populated from RQ transitionally" idea as the model-store bridge, but courseStatus is a
derivation across all three queries — which a per-query QueryCache.onSuccess can't express —
so it's a component-level hook, not part of the centralized bridge. Deleted when those readers
move to RQ.

The status reducers stay. fetchCourse is thinned, not deleted (see the scope section), so
checkFetchCourse stays too — it just dispatches the thinned fetchCourse (toggles only). RQ
auto-fetches on courseId change, so no separate metadata-fetch guard is needed.

CourseExit route → self-wrapping (+ transitional slice write for the exit children)

<TabContainer tab="courseware" fetch={fetchCourse}> becomes CourseExit self-wrapping
on the query hooks (rendering TabPage itself), matching the course-home tab pattern.

Why the transitional slice write: the course-exit children (CourseCelebration,
CourseNonPassing, CourseInProgress, and the recommendation/upgrade helpers) read
courseId from state.courseware — which fetchCourse used to set on this route. Without the
thunk, that slice field would be unset on the CourseExit route, so useModel('courseHomeMeta', undefined) would return {} and tabs.find(...) would throw. Rather than convert all ~7 children off
the slice (that's #1976's job), CourseExit writes courseId/courseStatus to the slice
transitionally via useCourseExitStatusBridge (courseware/data/statusBridge.ts) — the
CourseExit sibling of useCourseStatusBridge (2 queries, no outline). CourseExit owns the
two queries (it also feeds them to its own TabPage gating), so it passes them into the
bridge rather than the bridge returning them. The children keep reading the slice until they
convert.

useIFrameBehavior refetch → query invalidation

The iframe POST_EVENT handler's postEvent.mutate onSuccess did
dispatch(fetchCourse(courseId)); it now queryClient.invalidateQueries the three query keys
(coursewareQueryKeys.metadata, coursewareQueryKeys.outline, courseHomeQueryKeys.metadata)
via useQueryClient, so the refetch goes through RQ.

fetchCourse refetched a fourth thing — the sidebar toggles — but we deliberately don't
invalidate those here, and nothing else is needed for them: enableCompletionTracking is a
static course-level setting, and this trigger is a unit-level learner action (POST_EVENT)
that can't change it, so refetching it on every event was redundant. The three invalidated
queries cover exactly the data such an event can change (completion / gating / access), and
courseStatus re-derives via useCourseStatusBridge once those queries resettle.

Sidebar toggles peeled aside (to #2013)

getCoursewareOutlineSidebarTogglessetCoursewareOutlineSidebarToggles feeds only
the outline sidebar. It's the one fetch left in the thinned fetchCourse (which the
container still dispatches via the unchanged checkFetchCourse guard), so the setting keeps
loading until the sidebar layer (#2013) converts it and deletes fetchCourse. Not folded
into the query hooks here.

Align the toggle + outline-data peels with #1920's data split

Upstream PR #1920 ("Course outline restructure") splits the outline sidebar hook into
useCourseOutlineData() (the data: sections/sequences/units/courseOutlineStatus/
activeSequenceId/sequenceStatus, plus isEnabledCompletionTracking and
isActiveEntranceExam) and useCourseOutlineSidebar() (just open/collapse UI state). Notably
it moves isEnabledCompletionTracking from useCourseOutlineSidebar() to
useCourseOutlineData()
— i.e. the completion-tracking toggle is treated as outline data,
not sidebar chrome.

Implication for our decomposition (not #2010 — the transitional thunk just writes the slice,
which #1920 still reads via useSelector, so there's no conflict): when we convert these to
React Query, group the toggle conversion (#2013) with the outline-data conversion
(sections/sequences/units/status) so both feed useCourseOutlineData. Don't structure the
toggle as its own sidebar-flavored peel. Aligned that way, the eventual rebase over #1920 is
just "point useCourseOutlineData at the query hooks." (#1920 is UI-only — no data//thunk/
slice files — and currently stalled/red, so we align the split now and rebase whenever it moves.)

Test seeding (setupTest.js)

initializeTestStore (used by ~37 test files) seeds via executeThunk(fetchCourse).
Replace that with direct model-store dispatches replicating fetchCourse's writes
(coursewareMeta / courseHomeMeta / sections / sequences) from the same mocked data, so
the 37 callers keep working unchanged. Contained to setupTest.js.


Model-store mirror strategy

The coursewareMeta mirror merges, not replaces

Decision. useCoursewareMetadata mirrors its result into the coursewareMeta model
with strategy: 'updateModel' (merge), not addModel (replace).

Why. coursewareMeta[courseId] is written by two independent queries: the metadata
query (getCourseMetadata, whose payload has no sectionIds) and the outline query
(getLearningSequencesOutline, the only source of sectionIds, mirrored via
updateModelsMap on courses). addModel is a full replace (state[type][id] = model),
so when the metadata query resolved after the outline, it wiped the sectionIds the
outline had merged in. sequenceIdsSelector then returned [], so
useSequenceNavigationMetadata computed sequenceIndex = -1previousSequenceId = null
(couldn't go back a sequence) and isLastUnit true → next went to /course-end.
Intermittent, because it was a network-order race, re-rolled by the unit-completion query
invalidation. The old fetchCourse was immune: after Promise.allSettled it dispatched
addModel(metadata) then updateModelsMap(courses) synchronously, so the sectionIds
merge always ran last (there was even a comment saying so). updateModel restores that
guarantee regardless of resolution order; the payload carries id, and the bridge already
supports the strategy.

Tested. courseware/data/apiHooks.test.tsx renders both hooks through the bridged
query client with the metadata response deferred so its mirror lands last, then asserts
coursewareMeta.sectionIds and sequenceIdsSelector survive — RED with addModel, GREEN
with updateModel.

Audit: no other model-store mirror has the same exposure

Decision. Only the coursewareMeta metadata mirror needed the fix; the other mirrors
are correct as-is.

The rule. A replace-style mirror (addModel / addModelsMap) is only unsafe when
another writer contributes a field that the replacing query's own endpoint does not
return
— a cross-source field. coursewareMeta.sectionIds was the unique case (metadata
query replaces the model; sectionIds only ever comes from the outline endpoint).

Findings.

  • sectionsaddModelsMap (replace-per-section), but the outline is the only runtime
    writer, so nothing else's fields can be clobbered. Safe.
  • sequences — outline updateModelsMap + fetchSequence updateModel, both merge (the
    old code deliberately merged here: "sequence metadata may have come back first"). Safe.
  • coursewareMeta — after the fix, all three runtime writers (metadata mirror, outline
    mirror, a updateModel in thunks.js) merge. Safe.
  • courseHomeMeta (pre-existing, not refactor: convert the courseware metadata fetch to React Query #2023) — same shape (useCourseHomeMeta addModel
    replace vs celebration/streak updateModel), but benign: celebrations is part of the
    course-home metadata response (normalizeCourseHomeCourseMetadata spreads the whole
    payload) and is server-persisted (postCelebrationComplete), so a replace-refetch
    restores it — the field is same-source, unlike sectionIds. refetchOnWindowFocus is
    also false. No action.

@brian-smith-tcril brian-smith-tcril changed the title bsmith/react query courseware metadata refactor: convert the courseware metadata fetch to React Query Aug 21, 2026
@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.49%. Comparing base (4706a9a) to head (332f52e).

Additional details and impacted files
@@                                  Coverage Diff                                   @@
##           bsmith/react-query-courseware-container-typescript    #2023      +/-   ##
======================================================================================
- Coverage                                               93.53%   93.49%   -0.04%     
======================================================================================
  Files                                                     363      367       +4     
  Lines                                                    5905     5946      +41     
  Branches                                                 1367     1374       +7     
======================================================================================
+ Hits                                                     5523     5559      +36     
- Misses                                                    367      371       +4     
- Partials                                                   15       16       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@brian-smith-tcril brian-smith-tcril linked an issue Aug 21, 2026 that may be closed by this pull request
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch 2 times, most recently from 54b53a9 to b9eb81e Compare August 21, 2026 14:48
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch 2 times, most recently from 27fc4d9 to bd4c718 Compare August 21, 2026 18:24
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch from bd4c718 to 11de7ef Compare August 21, 2026 18:32
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch from 11de7ef to e145ef9 Compare August 21, 2026 18:34
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch from e145ef9 to b72083e Compare August 21, 2026 18:40
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch from b72083e to fb407a8 Compare August 21, 2026 18:55
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch from fb407a8 to 8d9a956 Compare August 21, 2026 19:05
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch from 8d9a956 to e861457 Compare August 21, 2026 19:15
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch from e861457 to 29b1274 Compare August 21, 2026 19:23
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch from 29b1274 to 462f871 Compare August 21, 2026 21:19
Extend the transitional QueryCache bridge so a query can mirror collection
results (and several model targets) into the models store, not just a single
addModel. Adds a `meta.models` list — each entry runs a model-store action
(addModel/updateModel/addModelsMap/updateModelsMap/updateModels) with an optional
`source` key into the result — while keeping the existing `{ modelType, courseId }`
single form. No runtime effect until a query opts in (wired up in the courseware
metadata conversion that follows in this PR).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch from 462f871 to a2eb96d Compare August 22, 2026 07:05
Convert fetchCourse's metadata/outline/courseHomeMeta fetches to React Query, mirroring
results into the model store via the bridge so the existing useModel readers keep working.

- Query hooks: useCoursewareMetadata + useCoursewareOutline (courseware/data/apiHooks +
  queryKeys); reuse useCourseHomeMeta (now typed — enabled guard + courseAccess). courseId is
  string|undefined from useParams with an `enabled` guard; key factories stay strict string.
- Status: CoursewareContainer and CourseExit call transitional bridges
  (courseware/data/statusBridge) that mirror the combined query state into
  state.courseware.courseStatus/courseId, so the redirect helpers/selectors, TabPage, and the
  exit-page children keep working until they move to React Query.
- fetchCourse is thinned to just the sidebar-toggles fetch (its full conversion is #2013); its
  model writes move to the model-store bridge and its status derivation to the status bridges.
- Query error logging: the app QueryCache onError logs failures; a query can override the level
  per HTTP status via meta.logStatusAs (the outline's expected 403 -> logInfo). meta is typed
  globally via a Register.queryMeta augmentation, so onError and the bridge read it cast-free.
- useIFrameBehavior's post-event refetch invalidates the courseware queries instead of
  dispatching fetchCourse.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch from a2eb96d to 332f52e Compare August 23, 2026 07:31
@brian-smith-tcril
brian-smith-tcril marked this pull request as ready for review August 23, 2026 07:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Convert courseware metadata to React Query Peel: extend the model-store bridge to collection dispatches

1 participant